Getting Started with MVVM using RxSwift

Preface

前陣子花了一些時間研究了一下 RxSwift 的使用,後來也使用 RxSwift 開發了一個 Video 解碼及顯示的小項目。

然而,因為需要針對公司產品開發一個簡單上手的 Quick Start App,想了想應該可以搬出 RxSwift 來套用,跟之前 Android 版本不同的是,這次的 iOS Quick Start App,我使用了 MVVM 這套設計模式來開發。

何謂 MVVM ?

所謂 MVVM 就是 Model + View + ViewModel 的合稱,是由 MVC 的一種變型,其設計理念就是: View → ViewModel → Model,也就是 View 引用了 ViewModel;ViewModel 引用了 Model。

為了能讓架構儘量單純化,所以 MVVM 架構不允許 View ← ViewModel ← Model 的這種使用方法。說到這邊也許會覺得奇怪,那麼如果 Model / View Model 的資料更改了,又怎麼能夠反應到 View 上呢?

這時候就可以借重這次的主題 RxSwift 來幫忙了。

RxSwift Demo

這次練習 APP 的 View 如下:

  • UITextField:用來輸入目標裝置的 ID。
  • UIButton:按鈕按下後將會記錄 Device ID 的值,並訪問 Web service Restful API。
  • UITableView:當成功新增一筆記錄後,將會自動至 Table 中;如果使用者刪除 Table 的記錄,則會呼叫 Web service Restful API 刪除 Server 上的記錄。

View

View Controller

在 AppDelegate 中必須取得 Remote Notification Token,並把 Token 值放入 Global 的 deviceToken 這個 property 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class AppDelegate: UIResponder, UIApplicationDelegate {

/* Skip dummy codes ... */

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.

let settings: UIUserNotificationSettings = UIUserNotificationSettings(forTypes: [.Alert, .Badge], categories: nil)
application.registerUserNotificationSettings(settings)
application.registerForRemoteNotifications()
application.applicationIconBadgeNumber = 0

return true
}

func application(application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: NSData) {
let characterSet: NSCharacterSet = NSCharacterSet(charactersInString: "<>")
let deviceTokenString: String = (deviceToken.description as NSString)
.stringByTrimmingCharactersInSet(characterSet)
.stringByReplacingOccurrencesOfString(" ", withString: "") as String

print("received device token: \(deviceTokenString)")
Global.sharedInstance.deviceToken = deviceTokenString
}
}

在 Global 這個類別,採用 Singleton 方法實作,其中 deviceToken 這個 property 是個 Observable 物件,所以當它被指定值 (didSet) 時則會被觸發事件 (onNext) 給 Observer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Global {

var deviceToken: String? {
didSet {
if let token = deviceToken {
rx_deviceToken.onNext(token)
}
}
}

var rx_deviceToken = BehaviorSubject<String>(value: "")

class var sharedInstance: Global {
struct Static {
static let instance: Global = Global()
}
return Static.instance
}
}

在 ViewController 中,可以看到在 ViewDidLoad 函式中逐一將 View 中的每個控件和 ViewModel 進行綁定 (Binding),之後如果數據在 ViewModel 中有更動時,透過 RxSwift 便會自動反應在 View 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class ViewController: UIViewController {
/* Skip dummy codes... */

let disposeBag = DisposeBag()
var viewModel: ViewModel!

override func viewDidLoad() {
super.viewDidLoad()

let textFieldValid =
textField.rx_text
.map { $0.characters.count == 20 }
.shareReplay(1)

let deviceTokenValid =
Global.sharedInstance.rx_deviceToken
.map { $0.characters.count == 64 }
.shareReplay(1)

Observable
.combineLatest(textFieldValid, deviceTokenValid) { $0 && $1 }
.shareReplay(1)
.bindTo(bindButton.rx_enabled)
.addDisposableTo(disposeBag)

viewModel = ViewModel(
deviceToken: Global.sharedInstance.rx_deviceToken,
text: textField.rx_text.asObservable(),
buttonTap: bindButton.rx_tap.asObservable(),
tableItemRemoved: tableView.rx_itemDeleted.asObservable())

viewModel.list.asObservable()
.bindTo(tableView.rx_itemsWithCellIdentifier("Cell")) { (_, element, cell) in
cell.textLabel?.text = element
}
.addDisposableTo(disposeBag)
}
}

ViewModel

我們把所有的程式邏輯都寫在 ViewModel 上,可以看到包含 UITextField 的輸入判斷,UIButton 的按鈕處理、Restful API 的使用等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class ViewModel {

private let serverURL = "http://push.iotcplatform.com/tpns"
private let disposeBag = DisposeBag()
let list: Variable<[String]>

init(deviceToken: Observable<String>,
text: Observable<String>,
buttonTap: Observable<Void>,
tableItemRemoved: Observable<NSIndexPath>) {

// load uid from UserDefaults
if let data: [String] = NSUserDefaults.standardUserDefaults().objectForKey("UIDs") as? [String] {
list = Variable<[String]>(data)
} else {
list = Variable<[String]>([])
}

/* Send to server when retrieve device token. */
deviceToken
.filter {
$0.characters.count == 64
}
.flatMapLatest { [unowned self] token in
return self.request(self.serverURL, parameters: ["cmd": "client", "os": "ios", "appid": "com.tutk.cc.samples.tpns.ios", "udid": token, "token": token])
}
.observeOn(MainScheduler.instance)
.subscribeNext {
print($0)
}
.addDisposableTo(disposeBag)

/* Check if the device id exists and add to the list when button tapped. */
buttonTap
.flatMapLatest {
text.take(1)
}
.filter { [unowned self] s -> Bool in
return !self.list.value.contains(s)
}
.subscribeNext { [unowned self] uid in
self.list.value.append(uid)
}
.addDisposableTo(disposeBag)

/* Remove the id from list */
tableItemRemoved
.map { return $0.row }
.subscribeNext { [unowned self] idx in
self.list.value.removeAtIndex(idx)
}
.addDisposableTo(disposeBag)

/* Whenever list modified, encode the ids and send to server. */
list.asObservable()
.flatMap { [unowned self] thiz in
return Observable.combineLatest(deviceToken, self.mapsyncString(thiz)) { ($0, $1) }
}
.flatMapLatest { [unowned self] (token, mapsync) in
return self.request(self.serverURL, parameters: ["cmd": "mapsync", "appid": "com.tutk.cc.samples.tpns.ios", "udid": token, "os": "ios", "map": mapsync.base64String()])
}
.observeOn(MainScheduler.instance)
.subscribeNext {
NSUserDefaults.standardUserDefaults().setObject(self.list.value, forKey: "UIDs")
NSUserDefaults.standardUserDefaults().synchronize()
print($0)
}
.addDisposableTo(disposeBag)
}

func request(url: String, parameters: [String: String]?) -> Observable<String> {
return RxAlamofire.request(.GET, url, parameters: parameters)
.flatMapLatest {
$0
.validate(statusCode: 200 ..< 300)
.rx_string()
}
}

func mapsyncString(list: [String]) -> Observable<String> {
let array: NSMutableArray = []
for uid in list {
array.addObject(NSMutableDictionary(object: uid, forKey: "uid"))
}

let json = JSON(array)

if let s = json.rawString() {
return Observable<String>.just(s)
} else {
return Observable<String>.just("[]")
}
}
}

結論

透過 RxSwift 我們可以很輕易地把數據綁定和程式邏輯分開處理,藉由這個優勢,可以降低程式的耦合程度,大幅提高程式可維護性。

完整程式已經放在 https://github.com/cloudhsiao/ios-tpns-quickstart,有興趣的朋友可以一同研究切磋。